v8.2.0
This release adds Advanced Filters (nested AND/OR condition groups with operators such as is_not, greater_than, is_empty) and Saved Views, which replace Index Preferences / Manage Columns. Saved views store filters, sort, visible columns, records per page, and view type for each user.
Breaking Changes
filter_with Scopes Now Receive a condition Argument
Every scope referenced by filter_with on a :single_select, :multi_select, :date, or :range filter is now called with two arguments: the filter value and the selected operator (condition).
| Filter type | Before | After |
|---|---|---|
:single_select, :multi_select | records.send(scope, value) | records.send(scope, value, condition) |
:date, :range | records.send(scope, from, to) | records.send(scope, value, condition) |
:search | records.send(scope, value) | Unchanged |
The date and range behaviour has changed most:
valueis now the raw string, for example"2026-01-01 to 2026-01-31"or"18 to 30". Previously the gem split it intofromandto.- For single-value operators (
greater_than,less_than,is, …),valueholds a single value with noto.
condition is nil for the standard filter bar. It is set only when the filter is used inside Advanced Filters.
Scopes defined with lambda / -> enforce arity strictly. A scope that still takes one argument (or from, to) raises ArgumentError when filtering. Update every filter_with scope before upgrading.
Available conditions
| Filter type | Conditions | Default |
|---|---|---|
:single_select, :multi_select | is, is_not, is_empty, is_not_empty | is |
:date | between, not_between, is_empty, is_not_empty | between |
:range | between, not_between, is, is_not, greater_than, less_than, greater_than_or_equal_to, less_than_or_equal_to, is_empty, is_not_empty | between |
Your scope is still called with is_empty and is_not_empty even when value is blank. Handle these conditions without relying on value.
Migration Steps
Add a condition parameter to every filter_with scope. Make it optional (condition = nil) so that existing direct callers keep working.
Select filters:
# Before
scope :custom_status_filter, ->(statuses) { where(status: statuses) }
# After
scope :custom_status_filter, lambda { |statuses, condition = nil|
case condition.to_s
when 'is_not' then where.not(status: statuses)
when 'is_empty' then where(status: nil)
when 'is_not_empty' then where.not(status: nil)
else where(status: statuses)
end
}
Date / range filters:
# Before
scope :custom_age_range, lambda { |from, to|
where('age BETWEEN ? AND ?', from.to_i, to.to_i) if from.present? && to.present?
}
# After
scope :custom_age_range, lambda { |value, condition = nil|
from, to = value.to_s.split(' to ')
case condition.to_s
when 'not_between' then where.not(age: from.to_i..to.to_i)
when 'greater_than' then where('age > ?', from.to_i)
when 'is_empty' then where(age: nil)
# ...handle the other operators you want to support
else where(age: from.to_i..to.to_i)
end
}
If your scope only wraps a column with extra joins or a different column, delegate the operator handling to the new helpers. They are available on every ActiveRecord model through CmAdmin::FilterConditions:
# Filter users by the country of their company (users -> companies)
scope :by_company_country, lambda { |value, condition = nil|
left_joins(:company).ct_dropdown_filter_condition(column: :country, value:, condition:, table: 'companies')
}
# Filter users by the size of their company
scope :by_company_size, lambda { |value, condition = nil|
left_joins(:company).ct_range_filter_condition(column: :employee_count, value:, condition:, table: 'companies')
}
filter :company_country, :multi_select, helper_method: :company_country_options, filter_with: :by_company_country
filter :company_size, :range, filter_with: :by_company_size
table: defaults to the model's own table. Pass the joined table's name whenever the column lives on an association.
Use left_joins rather than joins when you support is_empty. With an inner joins, records that have no associated row are dropped before the IS NULL check runs.
| Helper | Handles |
|---|---|
ct_dropdown_filter_condition(column:, value:, condition:, table: table_name) | is, is_not, is_empty, is_not_empty. Maps cm_empty to NULL |
ct_range_filter_condition(column:, value:, condition:, table: table_name) | All date and range operators. Parses the "from to to" string for you |
Index Preferences Replaced by Saved Page Views
The CmIndexPreference model and the cm_index_preferences table have been removed. Column visibility, records per page, filters, sort, and view type are now saved per user as named page views in a new ct_page_views table.
Migration Steps
- Run the CT Page View generator, then migrate:
rails generate cm_admin:ct_page_view
rails db:migrate
The generated migration:
- Enables the PostgreSQL
citextextension if it is not already enabled. The view name is case-insensitive. - Drops the
cm_index_preferencestable if it exists. - Creates
ct_page_viewswith a polymorphicowner,name,ar_model_name,associated_ar_model_name,visible_columns,hidden_columns,is_default,view_type,records_per_page,filter_params(jsonb), andsort_params(json). - Adds a partial unique index so that each owner has at most one default view per model / associated model.
Existing column preferences in cm_index_preferences are not migrated. Users have to recreate their column layouts as saved views. If you need to keep the data, back up the table or copy it into ct_page_views before running the migration.
- Remove any references to the old index preference API from your application:
| Removed | Replacement |
|---|---|
CmIndexPreference model | CtPageView |
cm_index_preference_<model>_path route helper | create_ct_page_view_<model>_path / update_ct_page_view_<model>_path |
reset_cm_index_preference_<model>_path route helper | Clear the active view (?clear_ct_page_view=true) |
cm_index_preference / reset_cm_index_preference controller actions | list_ct_page_view, create_ct_page_view, update_ct_page_view, destroy_ct_page_view, duplicate_ct_page_view |
CmAdmin::ViewHelpers::IndexPreferencePopupHelper | Partials under cm_admin/page_view/ and cm_admin/main/_manage_columns_modal |
@cm_index_preference instance variable | @current_ct_page_view |
-
Make sure the following are present in your host app. Most cm-admin installs already have them:
- The
DEFAULT_PER_PAGEconstant (generated inconfig/initializers/constants.rb).CtPageViewreferences it at load time.PER_PAGE_OPTIONSis optional and defaults to[10, 20, 50, 100]. Current.useris set for admin requests. It becomes theownerof each saved view.- PaperTrail's
versionstable.CtPageViewdeclareshas_paper_trail.
- The
-
Remove
cm_index_preferencesfromdb/schema.rb/structure.sqlonce migrated, and remove any factories, seeds, or specs that buildCmIndexPreferencerecords.
New routes
These routes are added automatically for every cm-admin model, with no setup needed:
| Verb | Path | Action |
|---|---|---|
| GET | /<models>/ct_page_views | list_ct_page_view |
| POST | /<models>/ct_page_views | create_ct_page_view |
| PUT | /<models>/ct_page_views/:id | update_ct_page_view |
| DELETE | /<models>/ct_page_views/:id | destroy_ct_page_view |
| POST | /<models>/ct_page_views/:id/duplicate | duplicate_ct_page_view |
All actions are scoped to owner: Current.user, so users can only see and change their own views.
Default views
A user can mark one view per list page as default. When that user opens the index page with no filters, current_ct_page_view_id, clear_filters, or clear_ct_page_view params, they are redirected to their default view. Deep links that include any of those params are not affected.
Behaviour Changes
filters URL Param Is Now Compressed
The filters query param on index and dashboard pages is now a zlib-compressed, base64url-encoded JSON string (produced with pako on the frontend), not a nested filters[...] hash. Compressing it keeps URLs short now that Advanced Filters can contain nested groups.
- Old links still work. A nested
filters[...]hash or a plain JSON string is still accepted. - If your application code reads
params[:filters]directly (custom controllers, helpers,table_source_methods, export hooks), decode it first:
# Before
filters = JSON.parse(params[:filters] || '{}')
# After
filters = CmAdmin::Utils.decode_filter_params(params[:filters]) || {}
- To generate a filtered link from Ruby, encode the hash:
cm_admin.cm_index_user_path(filters: CmAdmin::Utils.encode_filter_params({ single_select: { status: 'active' } }))
Dashboard widget source methods (charts, metrics, tables) still receive a decoded filters hash, so no change is needed there.
Sidebar query Items Linking to Filtered Index Pages
If your config.sidebar entries open a pre-filtered index page through query:, prefer moving that default into the filter's default_value: option instead of passing filters in the URL:
# Before — sidebar entry carrying a filters hash
{ display_name: 'Draft Posts', path: 'cm_index_post_path',
query: { filters: { single_select: { status: 'draft' } } } }
# After — the filter applies itself whenever no filter params are present
filter :status, :single_select, collection: %w[draft published], default_value: -> { 'draft' }
A filters hash in query: also suppresses the default-view redirect on every visit, so saved views never apply when the page is opened through that entry.
If a sidebar entry must keep its query: — for example, several entries pointing at different filtered states of the same index — include the current user's default view keys so the page resolves the correct view context:
{
display_name: 'Posts',
path: 'cm_index_post_path',
query: lambda {
view = CtPageView.find_by(owner: Current.user, is_default: true, ar_model_name: 'Post')
{
current_ct_page_view_id: view&.id,
filters: view&.filter_params
}
}
}
query accepts a proc that is evaluated on each request, which is required here because Current.user is only set while rendering. filter_params is a plain hash, so it serializes back into the nested filters[...] URL format, which is still supported.
CtPageView#apply_params returns the same keys (current_ct_page_view_id, filters, sort, view_type) already URL-ready, so query: -> { CtPageView.find_by(...)&.apply_params || {} } is a shorthand for the above.
Advanced Filters Override Standard Filters
When a request contains advanced filter conditions, all standard filter keys (date, range, single_select, multi_select) and their default_values are ignored. Only search and advanced are applied. Exports follow the same rule. If you rely on a default_value filter always being applied, enforce it with a policy scope or default scope instead.
Model#default_filters Signature
default_filters now also accepts a filter_list: keyword, alongside the existing action:. Existing calls such as model.default_filters(action:) keep working.
available_fields Uses Indifferent Access
CmAdmin::Model#available_fields is now a HashWithIndifferentAccess. Both available_fields[:index] and available_fields['index'] work. No action is needed.
Checklist
- Update every application
filter_withscope forselect,date, andrangefilters to accept(value, condition = nil). - Run
rails generate cm_admin:ct_page_view && rails db:migrate. Back upcm_index_preferencesfirst if you need the data. - Remove references to
CmIndexPreference,cm_index_preference_*/reset_cm_index_preference_*route helpers, and@cm_index_preference. - Confirm that
DEFAULT_PER_PAGE,Current.user, and PaperTrail are available. - Replace any direct
JSON.parse(params[:filters])withCmAdmin::Utils.decode_filter_params(params[:filters]). - Review sidebar
query:entries that injectfilters— preferdefault_value:on the filter, or includecurrent_ct_page_view_idandfiltersfrom the user's defaultCtPageView.